Jump to content

User:Suffusion of Yellow/batchtest-plus-core.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
/*
 * Adds a "Test against past hits" button to [[Special:AbuseFilter/test]].
 * Useful for testing your changes to a filter without tediously checking
 * each old hit with [[Special:AbuseFilter/examine]].
 *
 * Only the "user", "page", "before", and "after" fields are respected.
 */

// jshint esnext: false, esversion: 8
// <nowiki>
(function() {
	/* globals $, mw, OO */
	'use strict';

	// If forking, PLEASE change this line.
	const API_USER_AGENT = "batchtest-plus/0.5 (https://en.wikipedia.org/wiki/User:Suffusion_of_Yellow/batchtest-plus.js)";

	const DEFAULT_CONFIG = {
		"default" : {
			batchSize: 100, // Same as Special:AbuseFilter/test
			maxConcurrentRequests: 10, // Too many seems to cause random HTTP timeouts
			falsePositveTestFilter: false, // Filter at your wiki matching a random sample of edits
			enableFalseNegativeTest: false
		},
		"en.wikipedia.org" : {
			falsePositiveTestFilter: 1201,
			enableFalseNegativeTest: true
		}
	};

	let config = { }, api;

	function handleApiError(code, details) {
		if (typeof code != 'string')
			throw code; // Something went very wrong

		if (code == "http" && details.textStatus == "abort")
			return { aborted: true }; // Aborted by user, not an error

		return {
			error : (code == "http") ?
				"HTTP error: " + details.textStatus :
				"API returned error \"" + code + "\": " + details.error.info
		};
	}

	// Make API abuselog entry into something human-readable.
	function formatLogEntry(log) {
		let link = (target, text) =>
			$('<a></a>', {
				href: mw.util.getUrl(target),
				text: text
			});
		let sclink = (params, text) =>
			$('<a></a>', {
				href: new mw.Uri(mw.config.get('wgScript')).extend(params),
				text: text
			});
		let monthNamesShort =
			[ "", "Jan", "Feb", "Mar", "Apr", "May", "Jun",
			  "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" ];

		let t = log.timestamp;
		let date = parseInt(t.slice(8, 10)) +
            " " + monthNamesShort[parseInt(t.slice(5, 7))] +
            " " + t.slice(0, 4);
		let time = t.slice(11, 19);

		let $li = $('<li></li>').append(
			"(",
			link("Special:AbuseLog/" + log.id, "details"),
			" | ",
			link("Special:AbuseFilter/examine/log/" + log.id, "examine"),
			" | ",
			log.revid ? link("Special:Diff/" + log.revid, "diff") : "diff",
			") . . ",
			link("Special:AbuseFilter/" + log.filter_id,
				 log.filter_id).attr("title", log.filter),
			" (",
			mw.html.escape(log.action),
			" -> ",
			$('<span></span>', {
				text : log.result || "none",
				class : "filter-highlighter-" + (log.result || "noaction")
			}),
			") . . ",
			link(log.title, log.title),
			" (",
			log.title.indexOf("Special:") !== 0 ?
				sclink( { title : log.title,
						  action : "history" }, "hist") : "hist",
			" | ",
			sclink( { title: "Special:AbuseLog",
					  wpSearchTitle : log.title }, "log"),
			"); ",
			mw.html.escape(time),
			" . . ",
			link("Special:Contributions/" + log.user, log.user),
			" (",
			link("User talk:" + log.user, "talk"),
			" | ",
			sclink( { title: "Special:AbuseLog",
					  wpSearchUser : log.user }, "log"),
			")"
		);

		$li.addClass("btp-maybematch");

		return [date, $li];
	}

	async function doTest(abuselog, filter, stats, cb) {
		let pending = [];

		for(let log of abuselog) {
			let idx = pending.length < config.maxConcurrentRequests ?
				pending.length : await Promise.race(pending);

			if (idx === undefined)
				break; // Something went wrong with the last request

			pending[idx] = api.post({
				action : 'abusefiltercheckmatch',
				filter : filter,
				logid : log.id
			}).catch(handleApiError)
				.then(response => {
					let result;

					if (response.aborted)
						return;

					stats.tested++;

					if (!response || !response.abusefiltercheckmatch) {
						stats.errors++;
						result = null;
					} else {
						result = response.abusefiltercheckmatch.result;
					}

					if (result)
						stats.matches++;

					if (cb)
						cb(log.id, result, response.error);

					return idx;
				});
		}

		await Promise.all(pending);

		return stats;
	}

	async function testAtTestPage(filters, query, testFilter, action, stats) {
		if (!query) {
			// We got here because the user clicked "Test".
			// If they had clicked "continue", query would be defined.

			stats = {
				tested : 0,
				errors: 0,
				matches : 0
			};

			query = {
				action : "query",
				list : "abuselog",
				aflprop : "ids|filter|user|title|action|result|timestamp|revid",
				afllimit : config.batchSize
			};

			let user = $('[name="wpTestUser"]').val();
			let title = $('[name="wpTestPage"]').val();
			let after = $('[name="wpTestPeriodStart"]').val();
			let before = $('[name="wpTestPeriodEnd"]').val();
			testFilter = $('[name="wpFilterRules"]').val();
			action = $('[name="wpTestAction"]').val();

			if (filters.length)
				query.aflfilter = filters;
			if (user.length)
				query.afluser = user;
			if (title.length)
				query.afltitle = title;
			if (before.length)
				query.aflstart = before;
			if (after.length)
				query.aflend = after;

			// Cleanup last run, or the normal /test results
			$('.mw-changeslist, .btp-results, .btp-progress').remove();

			mw.util.$content.append('<div class="btp-results"></div>');
			mw.util.$content.append('<h4 class="btp-progress"></h4>');
		}

		let response = await api.get(query).catch(handleApiError);

		if (!response || !response.query || !response.query.abuselog) {
			if (response.error)
				mw.notify(response.error);
			return;
		}

		let abuselog = response.query.abuselog
			.filter((log) => (action === "0" || log.action.includes(action)));

		let $results = $('<div></div>'), $loglines = {};
		let prev = $(".btp-results").find('h4').last().text();

		let $ul = $('<ul></ul>');
		$results.append($ul);

		for(let log of abuselog) {
			let [date, $li] = formatLogEntry(log);

			$loglines[log.id] = $li;

			if (date != prev ) {
				prev = date;

				$ul = $('<ul></ul>');
				$results.append($('<h4></h4>', { text: date }), $ul);
			}

			$ul.append($li);
		}

		$('.btp-results').append($results);

		await doTest(abuselog, testFilter, stats, (id, result, err) => {
			$loglines[id].removeClass('btp-maybematch').removeAttr("title");

            if (result === null) {
                $loglines[id].addClass('btp-error').attr("title", err);
            } else if (result === true) {
                $loglines[id].addClass('btp-match');
            } else {
                $loglines[id].addClass('btp-nomatch');
            }
		});


        let $summary = $('<h4></h4>').append(
            $('<span></span>', {
                text: stats.matches + "/" + stats.tested + " match, " + stats.errors + " error(s)"
            })
		);

		if (response.continue) {
			$summary.append(
				", ",
                $('<a></a>', {
                    text: "continue?"
                }).click(() => {
                    $summary.remove();

                    query.aflstart = response.continue.aflstart;
                    testAtTestPage(filters, query, testFilter, action, stats);
                })
			);
        }
        $results.append($summary);

		// For popups/markblocked/filter-highlighter/etc.
		mw.hook('wikipage.content').fire($results);
	}

	async function testAtFilterEditor(filterRules, id) {
		let stats = {
			tested : 0,
			errors: 0,
			matches : 0
		};

		let query = {
			action : "query",
			list : "abuselog",
			aflprop : "ids|filter",
			afllimit : config.batchSize,
			aflfilter : id
		};

		$('.btp-progress').text("Fetching filter log...");

		let response = await api.get(query).catch(handleApiError);

		if (!response || !response.query || !response.query.abuselog || !response.query.abuselog.length) {
			$('.btp-progress').text("Failed to fetch filter log");
			return;
		}

		await doTest(response.query.abuselog, filterRules, stats, () => {
			$('.btp-progress').text(
				stats.matches + "/" + stats.tested + " match, "
					+ stats.errors + " error(s) (Filter rule: "
					+ response.query.abuselog[0].filter + ")"
			);
		});
	}

	function setupFilterEditor() {
		let $form = $("#mw-abusefilter-editing-form");
		let $saveButton = $form.find("input[type=submit]");

		let FPButton, FNButton;

		if (config.falsePositiveTestFilter) {
			FPButton = new OO.ui.ButtonWidget({
				label: 'FP check',
				title: 'Check for false positives'
			}).on("click", async () => {
				api.abort();
				testAtFilterEditor($("#wpFilterRules").val(), config.falsePositiveTestFilter);
			});
		}

		let id = $form.attr("action") && $form.attr("action").match(/\d+$/);

		if (config.enableFalseNegativeTest && id) {
			FNButton = new OO.ui.ButtonWidget({
				label: 'FN check',
				title: 'Check for false negatives'
			}).on("click", () => {
				api.abort();
				testAtFilterEditor($("#wpFilterRules").val(), id[0]);
			});
		}

		$saveButton.parent().after(
			FPButton && FPButton.$element,
			FNButton && FNButton.$element,
			$('<span style="font-size:85%"></span>').html('<a href="https://en.wikipedia.org/wiki/User:Suffusion_of_Yellow/batchtest-plus">What\'s this?')
		);

		$form.append($('<div class="btp-progress"></div>'));
	}

	function setupTestPage() {
		let filterId = mw.config.get('wgPageName').match(/\/(\d+)$/);
		let filters = new OO.ui.TextInputWidget({
			placeholder: "Filter IDs (separate with pipes)",
			value: filterId && filterId[1]
		});

		let test = new OO.ui.ButtonWidget({
			label: "Test"
		}).on("click", () => {
			api.abort();
			testAtTestPage(filters.getValue());
		});

		let cancel = new OO.ui.ButtonWidget({
			label: "Cancel"
		}).on("click", () => {
			api.abort();
		});

		let fieldset = new OO.ui.FieldsetLayout({
			label: "Test against past hits"
		}).addItems([new OO.ui.HorizontalLayout({
			items: [filters, test, cancel]
		})]);

		$('#wpFilterForm').append(fieldset.$element);
	}

	function setup() {
		Object.assign(config, DEFAULT_CONFIG['default']);
		Object.assign(config, DEFAULT_CONFIG[mw.config.get('wgServerName')]);
		if(window.batchTestPlusConfig) {
			Object.assign(config, window.batchTestPlusConfig['default']);
			Object.assign(config, window.batchTestPlusConfig[mw.config.get('wgServerName')]);
		}

		api = new mw.Api( {
			ajax: {
				headers: {
					'Api-User-Agent' : API_USER_AGENT
				}
			}
		});

		if (/\/test(\/\d+)?$/.test(mw.config.get('wgPageName')))
			setupTestPage();
		else if ($('#mw-abusefilter-editing-form') &&
				 (config.falsePositveTestFilter || config.enableFalseNegativeCheck));
			setupFilterEditor();
	}

	if (mw.config.get('wgCanonicalSpecialPageName') === 'AbuseFilter') {
		$.when($.ready,
			   mw.loader.load("https://en.wikipedia.org/w/index.php?action=raw&title=User:Suffusion_of_Yellow/batchtest-plus.css&ctype=text/css", "text/css"),
			   mw.loader.using(
				   ['mediawiki.util',
					'mediawiki.api',
					'mediawiki.Uri',
					'oojs-ui-core'])).then(setup);
	}
})();
// </nowiki>